@reblu/site-blocks 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @reblu/site-blocks
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 38d27c5: Add `ProfessionalFields`, a drop-in for the tenant-defined professional fields on a team profile (`professional.fields`, e.g. a registration number). `ProfileCard` gains an optional `fields` prop that shows the card fields. Additive: existing usage renders as before.
8
+ - 5ef5630: Add `ProductCarousel` on `@reblu/site-blocks/client`, a drop-in product strip for «Productos relacionados». It scrolls natively with scroll-snap, so touch users swipe it, and has previous/next arrows from `sm` up. The arrows are hidden when everything fits and disabled at the end you have reached. The track is a focusable, labelled region and arrow scrolling respects reduced motion. It renders `ProductCard` by default, or your own card through `renderProduct`, and needs no carousel dependency. Additive: no existing export changes.
9
+
10
+ ### Patch Changes
11
+
12
+ - 5ef5630: Every button the organisms render shows the pointer cursor on hover under Tailwind v4: `AddToCartButton`, the `CartLineList` quantity stepper and remove button, `CheckoutForm`, `CheckoutLoginPrompt`, `Collection` pagination and facet chips, `DataRequestForm`, `OrderConfirmation` and the `ShareButtons` copy button. Disabled buttons show `cursor-not-allowed`. A source guard (`test/button-cursor.test.ts`) fails on any new `<button>` without the pointer cursor.
13
+ - 49384d1: README: document the `MarkdownRenderer` `images` option (URL-prefix allowlist, custom `render`, `<figure>` captions) with a `next/image` example. No code change.
14
+ - Updated dependencies [95f5b5f]
15
+ - Updated dependencies [5ef5630]
16
+ - Updated dependencies [e27e1b0]
17
+ - @reblu/site-contracts@1.19.0
18
+ - @reblu/site-ui@0.7.2
19
+
3
20
  ## 0.18.0
4
21
 
5
22
  ### Minor Changes
package/README.md CHANGED
@@ -26,6 +26,7 @@ colour on the red background. See `docs/theming/theming.md`.
26
26
  | `MarkdownRenderer` | `.` | Canonical Blog renderer: `marked` → **hast-util-sanitize** (XSS) → component map, with a YouTube-domain allowlist. RSC-safe. |
27
27
  | `CourseDescription` | `.` | Drop-in for the rich-text course `description` (`client.academy.bySlug()`). Server Component. See "Rich-text descriptions". |
28
28
  | `Treatment`, `ParentPage`, `ProfileCard`, `ProductCard` | `.` | Presentational organisms over the SITE API contracts. RSC-safe. `Specialty`: deprecated alias, see below. |
29
+ | `ProfessionalFields` | `.` | The tenant's own professional fields (`professional.fields`) on a team card or profile. RSC-safe. See below. |
29
30
  | `ShopFilters` | `.` | Plug-and-play shop filter chrome over `GET /api/site/shop/facets`. RSC-safe. See "Shop filters" below. |
30
31
  | `CartLineList`, `CartSummary`, `CartAdjustmentsNotice`, `AddedToCartToast`, `CheckoutLoginPrompt`, `CheckoutForm`, `OrderConfirmation` | `.` | Cart & checkout organisms. RSC-safe by construction. See "Cart & checkout" below. |
31
32
  | `PageBlocks`, `FaqAccordion` | `.` | Registry-block renderer (`PageDetail.blocks`) and native `<details>` FAQ. Server Components. See "Page blocks" below. |
@@ -37,6 +38,7 @@ colour on the red background. See `docs/theming/theming.md`.
37
38
  | `ShareButtons` | `./client` | Social share (X / LinkedIn / WhatsApp / copy). |
38
39
  | `AddToCartButton`, `CartIcon` | `./client` | Add-to-cart feedback (REB-837) — **moved here from `.`**, see below. |
39
40
  | `DataRequestForm` | `./client` | Drop-in public GDPR rights form over `POST /api/site/data-requests` (REB-980). See "GDPR rights form" below. |
41
+ | `ProductCarousel` | `./client` | Scroll-snap product strip with prev/next arrows («Productos relacionados»). See "Product carousel" below. |
40
42
 
41
43
  ## Boundary
42
44
 
@@ -171,6 +173,39 @@ Internal links (`/privacidad`, `#clause-4`) and `mailto:` links open in the same
171
173
  tab. External links (`https://…`, `//…`) open in a new tab and always carry
172
174
  `rel="noopener noreferrer"`.
173
175
 
176
+ ### Images: the `images` option
177
+
178
+ Without `images`, every image the sanitiser keeps renders as a lazy `<img>`
179
+ inside its paragraph. Pass `images` to restrict where they may load from and,
180
+ optionally, how they render:
181
+
182
+ ```tsx
183
+ import Image from 'next/image'
184
+
185
+ const MEDIA = 'https://<ref>.supabase.co/storage/v1/object/public/blog-media/'
186
+
187
+ <MarkdownRenderer
188
+ content={post.content}
189
+ headingOffset={1}
190
+ normalizeHeadings
191
+ images={{
192
+ // Absolute URL prefixes (ending in `/`); any other image is dropped.
193
+ allowedUrlPrefixes: [MEDIA],
194
+ // Optional: render an allowed image yourself. Default: a lazy <img>.
195
+ render: ({ src, alt }) => <Image src={src} alt={alt} width={1600} height={900} />,
196
+ }}
197
+ />
198
+ ```
199
+
200
+ - The prefix is compared on the parsed, normalised URL (`..` resolved, scheme
201
+ included), so `http://…` never matches an `https://…` prefix.
202
+ - A paragraph holding only an allowed image becomes a `<figure>`; the image
203
+ title (`![alt](url "caption")`) is its `<figcaption>`.
204
+ - `render` receives `{ src, alt }` only: give `next/image` a size you know
205
+ (e.g. encoded in the object name) or use `fill` inside a sized box, and allow
206
+ the host in `images.remotePatterns`.
207
+ - `SafeHtml` has no `images` option: its images keep the default rendering.
208
+
174
209
  ### Which authored attributes survive
175
210
 
176
211
  The renderer rebuilds each element to apply the tenant's design tokens and to
@@ -764,6 +799,94 @@ export async function ServicesTeaser() {
764
799
  }
765
800
  ```
766
801
 
802
+ ## Professional fields — `ProfessionalFields` (REB-1099)
803
+
804
+ Each tenant defines its own fields for team profiles in nexus › Equipo ›
805
+ «Campos profesionales» — for example a registration number, a licence or a
806
+ membership id — and marks which are public and which also go on the card. The
807
+ SITE API serves the public ones as `professional.fields`
808
+ (`{ label, value, showOnCard }[]`): the list (`client.pages.list`) carries only
809
+ the card fields, the profile (`client.pages.bySlug`) every public one. The
810
+ labels are the tenant's; this package names no field.
811
+
812
+ On the team card, `ProfileCard` takes them directly:
813
+
814
+ ```tsx
815
+ import { ProfileCard } from '@reblu/site-blocks'
816
+
817
+ const { data: team } = await client.pages.list({ type: ['team'] })
818
+
819
+ {team.map((p) => (
820
+ <ProfileCard
821
+ key={p.slug}
822
+ name={p.title}
823
+ role={p.professional?.role ?? undefined}
824
+ imageUrl={p.coverImageUrl ?? undefined}
825
+ fields={p.professional?.fields}
826
+ href={`/equipo/${p.slug}`}
827
+ />
828
+ ))}
829
+ ```
830
+
831
+ On the profile page, drop in `ProfessionalFields` (a `<dl>`):
832
+
833
+ ```tsx
834
+ import { ProfessionalFields } from '@reblu/site-blocks'
835
+
836
+ const page = await client.pages.bySlug(slug)
837
+
838
+ <ProfessionalFields fields={page.professional?.fields} />
839
+ ```
840
+
841
+ `variant='card'` keeps only `showOnCard` fields. It renders nothing when there
842
+ is nothing to show, so it is safe to place unconditionally. Style it with
843
+ `className` or `[data-slot="professional-fields"]`.
844
+
845
+ ## Product carousel — `ProductCarousel` (REB-1120)
846
+
847
+ A horizontal product strip for «Productos relacionados» or «También te puede
848
+ interesar», with no carousel dependency. The track is a native scroll container
849
+ with CSS scroll-snap, so touch users swipe it. From `sm` up, previous/next
850
+ arrows scroll one visible page. The arrows are not rendered when every product
851
+ fits, and the arrow at the end you have reached is disabled.
852
+
853
+ ```tsx
854
+ 'use client'
855
+ import { ProductCarousel } from '@reblu/site-blocks/client'
856
+
857
+ // `related` is a `ShopProductListItem[]`, e.g. from `client.shop.products({ category })`
858
+ <ProductCarousel
859
+ products={related}
860
+ heading='También te puede interesar'
861
+ productHref={(p) => `/shop/${p.slug}`}
862
+ />
863
+ ```
864
+
865
+ It renders the package's `ProductCard` by default. To use your own card, pass
866
+ `renderProduct`. Any item with an `id` works:
867
+
868
+ ```tsx
869
+ <ProductCarousel
870
+ products={related}
871
+ heading='También te puede interesar'
872
+ renderProduct={(p) => <MyProductCard product={p} />}
873
+ />
874
+ ```
875
+
876
+ - **Accessibility.** The track is a focusable `region` with
877
+ `aria-roledescription="carrusel"`. It is named by `heading` when you pass one,
878
+ otherwise by `labels.region`. Once the track has focus, the keyboard arrow
879
+ keys scroll it. The arrows are `<button>`s with `aria-label`s and
880
+ `aria-controls` pointing at the track. Under `prefers-reduced-motion: reduce`
881
+ they jump instead of animating.
882
+ - **Copy.** The defaults are Spanish (`DEFAULT_PRODUCT_CAROUSEL_LABELS`).
883
+ Override them with `labels={{ region, roleDescription, previous, next }}`.
884
+ - **Layout.** About 1.5 cards are visible on mobile, so the next one peeks in,
885
+ then 3 from `sm` and 4 from `lg`. Change the width with `itemClassName`
886
+ (e.g. `basis-64`). Style it with `className` or `[data-slot="product-carousel"]`.
887
+ `headingClassName` replaces the heading's typography (see the className rule
888
+ above). It renders nothing for an empty list.
889
+
767
890
  ## Page blocks
768
891
 
769
892
  `PageBlocks` renders a page's registry blocks (`PageDetail.blocks` from
@@ -0,0 +1,4 @@
1
+ /** A heading level a caller can configure — `h1`…`h6`. */
2
+ type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
3
+
4
+ export type { HeadingLevel as H };
@@ -0,0 +1,4 @@
1
+ /** A heading level a caller can configure — `h1`…`h6`. */
2
+ type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
3
+
4
+ export type { HeadingLevel as H };
package/dist/client.cjs CHANGED
@@ -37,7 +37,9 @@ __export(client_exports, {
37
37
  DATA_REQUEST_FIELD_LIMITS: () => DATA_REQUEST_FIELD_LIMITS,
38
38
  DATA_REQUEST_TYPES: () => import_site_contracts.DATA_REQUEST_TYPES,
39
39
  DEFAULT_DATA_REQUEST_FORM_LABELS: () => DEFAULT_DATA_REQUEST_FORM_LABELS,
40
+ DEFAULT_PRODUCT_CAROUSEL_LABELS: () => DEFAULT_PRODUCT_CAROUSEL_LABELS,
40
41
  DataRequestForm: () => DataRequestForm,
42
+ ProductCarousel: () => ProductCarousel,
41
43
  ShareButtons: () => ShareButtons,
42
44
  useCollection: () => useCollection
43
45
  });
@@ -226,7 +228,7 @@ function Collection({
226
228
  ] })
227
229
  ] });
228
230
  }
229
- var PAGE_BTN_CLASS = "inline-flex h-9 items-center rounded-md border border-border px-4 text-sm font-medium transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
231
+ var PAGE_BTN_CLASS = "inline-flex h-9 cursor-pointer items-center rounded-md border border-border px-4 text-sm font-medium transition-colors hover:bg-muted disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
230
232
  function FacetChip({ label, pressed, color, onClick }) {
231
233
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
232
234
  "button",
@@ -236,7 +238,7 @@ function FacetChip({ label, pressed, color, onClick }) {
236
238
  onClick,
237
239
  style: color ? { "--facet-color": color } : void 0,
238
240
  className: (0, import_utils.cn)(
239
- "inline-flex items-center rounded-full border px-3 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
241
+ "inline-flex cursor-pointer items-center rounded-full border px-3 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
240
242
  pressed ? color ? "border-transparent bg-[var(--facet-color)] text-white" : "border-transparent bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:bg-muted hover:text-foreground"
241
243
  ),
242
244
  children: label
@@ -321,7 +323,7 @@ function ShareButtons({ url, title, className, labels }) {
321
323
  {
322
324
  type: "button",
323
325
  onClick: handleCopy,
324
- className: (0, import_utils2.cn)(LINK_CLASS, "w-auto gap-2 px-3", copied && "border-primary text-primary"),
326
+ className: (0, import_utils2.cn)(LINK_CLASS, "w-auto cursor-pointer gap-2 px-3", copied && "border-primary text-primary"),
325
327
  "aria-label": t.copy,
326
328
  children: [
327
329
  copied ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(CheckIcon, {}) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(CopyIcon, {}),
@@ -442,7 +444,7 @@ function AddToCartButton({
442
444
  "data-slot": "add-to-cart-button",
443
445
  "data-status": status,
444
446
  className: (0, import_utils3.cn)(
445
- "inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
447
+ "inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
446
448
  isDisabled ? "cursor-not-allowed bg-muted text-muted-foreground" : "bg-primary text-primary-foreground hover:bg-primary/90",
447
449
  status === "error" && "bg-destructive text-destructive-foreground",
448
450
  className
@@ -527,11 +529,214 @@ function CartIcon({ itemCount, href, hydrated, bumpOnAdd, labels, className }) {
527
529
  );
528
530
  }
529
531
 
530
- // src/components/data-request-form.tsx
532
+ // src/components/product-carousel.tsx
531
533
  var import_react4 = require("react");
534
+ var import_button_variants = require("@reblu/site-ui/button-variants");
535
+ var import_utils6 = require("@reblu/site-ui/utils");
536
+
537
+ // src/components/_heading.tsx
538
+ var import_jsx_runtime5 = require("react/jsx-runtime");
539
+ function Heading({
540
+ level,
541
+ className,
542
+ children
543
+ }) {
544
+ const Tag = `h${level}`;
545
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(Tag, { className, children });
546
+ }
547
+
548
+ // src/components/product-card.tsx
532
549
  var import_utils5 = require("@reblu/site-ui/utils");
550
+
551
+ // src/lib/format-price.ts
552
+ function formatPrice(cents, currency = "EUR", locale = "es-ES") {
553
+ return new Intl.NumberFormat(locale, {
554
+ style: "currency",
555
+ currency
556
+ }).format(cents / 100);
557
+ }
558
+
559
+ // src/components/product-card.tsx
560
+ var import_jsx_runtime6 = require("react/jsx-runtime");
561
+ function ProductCard({ product, href, currencyCode = "EUR", locale, className }) {
562
+ const image = product.images.find((i) => i.isPrimary) ?? product.images[0];
563
+ const hasDiscount = product.comparePrice != null && product.comparePrice > product.price;
564
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
565
+ "article",
566
+ {
567
+ className: (0, import_utils5.cn)(
568
+ "group flex flex-col overflow-hidden rounded-lg border bg-card transition-shadow hover:shadow-lg",
569
+ className
570
+ ),
571
+ "data-slot": "product-card",
572
+ children: [
573
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "relative aspect-square overflow-hidden bg-muted", children: image ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
574
+ "img",
575
+ {
576
+ src: image.url,
577
+ alt: image.alt ?? product.name,
578
+ loading: "lazy",
579
+ className: "h-full w-full object-cover transition-transform group-hover:scale-105"
580
+ }
581
+ ) : /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "flex h-full items-center justify-center text-4xl text-muted-foreground/30", "aria-hidden": "true", children: "\u{1F6CD}\uFE0F" }) }),
582
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "flex flex-1 flex-col gap-2 p-4", children: [
583
+ product.category && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "inline-flex w-fit items-center rounded-full bg-secondary px-2.5 py-0.5 text-xs font-medium text-secondary-foreground", children: product.category.name }),
584
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("h3", { className: "text-base font-semibold leading-tight", children: href ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("a", { href, className: "transition-colors hover:text-primary", children: product.name }) : product.name }),
585
+ product.shortDescription && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("p", { className: "line-clamp-2 text-sm text-muted-foreground", children: product.shortDescription }),
586
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "mt-auto flex items-baseline gap-2 pt-2", children: [
587
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "text-lg font-bold text-foreground", children: formatPrice(product.price, currencyCode, locale) }),
588
+ hasDiscount && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "text-sm text-muted-foreground line-through", children: formatPrice(product.comparePrice, currencyCode, locale) })
589
+ ] })
590
+ ] })
591
+ ]
592
+ }
593
+ );
594
+ }
595
+
596
+ // src/components/product-carousel.tsx
597
+ var import_jsx_runtime7 = require("react/jsx-runtime");
598
+ var DEFAULT_PRODUCT_CAROUSEL_LABELS = {
599
+ region: "Productos relacionados",
600
+ roleDescription: "carrusel",
601
+ previous: "Anterior",
602
+ next: "Siguiente"
603
+ };
604
+ var EDGE_EPSILON = 1;
605
+ function prefersReducedMotion() {
606
+ return typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)").matches : false;
607
+ }
608
+ function ChevronIcon({ direction }) {
609
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
610
+ "svg",
611
+ {
612
+ "aria-hidden": "true",
613
+ viewBox: "0 0 24 24",
614
+ fill: "none",
615
+ stroke: "currentColor",
616
+ strokeWidth: 2,
617
+ strokeLinecap: "round",
618
+ strokeLinejoin: "round",
619
+ className: "rtl:-scale-x-100",
620
+ children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("path", { d: direction === "left" ? "m15 18-6-6 6-6" : "m9 18 6-6-6-6" })
621
+ }
622
+ );
623
+ }
624
+ function ProductCarousel(props) {
625
+ const { products, heading, headingLevel = 2, headingClassName, labels, className, itemClassName } = props;
626
+ const t = { ...DEFAULT_PRODUCT_CAROUSEL_LABELS, ...labels };
627
+ const trackId = (0, import_react4.useId)();
628
+ const headingId = (0, import_react4.useId)();
629
+ const trackRef = (0, import_react4.useRef)(null);
630
+ const [canPrev, setCanPrev] = (0, import_react4.useState)(false);
631
+ const [canNext, setCanNext] = (0, import_react4.useState)(false);
632
+ const update = (0, import_react4.useCallback)(() => {
633
+ const el = trackRef.current;
634
+ if (!el) return;
635
+ const max = el.scrollWidth - el.clientWidth;
636
+ const pos = Math.abs(el.scrollLeft);
637
+ setCanPrev(pos > EDGE_EPSILON);
638
+ setCanNext(pos < max - EDGE_EPSILON);
639
+ }, []);
640
+ (0, import_react4.useEffect)(() => {
641
+ const el = trackRef.current;
642
+ if (!el) return;
643
+ update();
644
+ el.addEventListener("scroll", update, { passive: true });
645
+ let observer;
646
+ if (typeof ResizeObserver !== "undefined") {
647
+ observer = new ResizeObserver(update);
648
+ observer.observe(el);
649
+ } else {
650
+ window.addEventListener("resize", update);
651
+ }
652
+ return () => {
653
+ el.removeEventListener("scroll", update);
654
+ if (observer) observer.disconnect();
655
+ else window.removeEventListener("resize", update);
656
+ };
657
+ }, [update, products.length]);
658
+ const scrollByPage = (direction) => {
659
+ const el = trackRef.current;
660
+ if (!el) return;
661
+ const rtl = typeof getComputedStyle === "function" && getComputedStyle(el).direction === "rtl";
662
+ el.scrollBy({
663
+ left: direction * el.clientWidth * (rtl ? -1 : 1),
664
+ behavior: prefersReducedMotion() ? "auto" : "smooth"
665
+ });
666
+ };
667
+ if (products.length === 0) return null;
668
+ const scrollable = canPrev || canNext;
669
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("section", { "data-slot": "product-carousel", className: (0, import_utils6.cn)("flex flex-col gap-4", className), children: [
670
+ (heading || scrollable) && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "flex items-center justify-between gap-4", children: [
671
+ heading ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { id: headingId, children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(Heading, { level: headingLevel, className: headingClassName ?? "text-xl font-semibold", children: heading }) }) : /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", {}),
672
+ scrollable && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "hidden gap-2 sm:flex", "data-slot": "product-carousel-controls", children: [
673
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
674
+ "button",
675
+ {
676
+ type: "button",
677
+ className: (0, import_utils6.cn)((0, import_button_variants.buttonVariants)({ variant: "outline", size: "icon" }), "rounded-full"),
678
+ "aria-label": t.previous,
679
+ "aria-controls": trackId,
680
+ disabled: !canPrev,
681
+ onClick: () => scrollByPage(-1),
682
+ children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(ChevronIcon, { direction: "left" })
683
+ }
684
+ ),
685
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
686
+ "button",
687
+ {
688
+ type: "button",
689
+ className: (0, import_utils6.cn)((0, import_button_variants.buttonVariants)({ variant: "outline", size: "icon" }), "rounded-full"),
690
+ "aria-label": t.next,
691
+ "aria-controls": trackId,
692
+ disabled: !canNext,
693
+ onClick: () => scrollByPage(1),
694
+ children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(ChevronIcon, { direction: "right" })
695
+ }
696
+ )
697
+ ] })
698
+ ] }),
699
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
700
+ "div",
701
+ {
702
+ ref: trackRef,
703
+ id: trackId,
704
+ role: "region",
705
+ "aria-roledescription": t.roleDescription,
706
+ ...heading ? { "aria-labelledby": headingId } : { "aria-label": t.region },
707
+ tabIndex: 0,
708
+ "data-slot": "product-carousel-track",
709
+ className: "snap-x snap-mandatory overflow-x-auto overscroll-x-contain scroll-smooth rounded-lg pb-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:scroll-auto",
710
+ children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("ul", { role: "list", className: "m-0 flex list-none gap-4 p-0", children: products.map((product, index) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
711
+ "li",
712
+ {
713
+ className: (0, import_utils6.cn)(
714
+ "shrink-0 snap-start basis-[calc((100%-1rem)/1.5)] sm:basis-[calc((100%-2rem)/3)] lg:basis-[calc((100%-3rem)/4)]",
715
+ itemClassName
716
+ ),
717
+ children: props.renderProduct ? props.renderProduct(product, index) : /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
718
+ ProductCard,
719
+ {
720
+ product,
721
+ href: props.productHref?.(product),
722
+ currencyCode: props.currencyCode,
723
+ locale: props.locale,
724
+ className: "h-full"
725
+ }
726
+ )
727
+ },
728
+ product.id
729
+ )) })
730
+ }
731
+ )
732
+ ] });
733
+ }
734
+
735
+ // src/components/data-request-form.tsx
736
+ var import_react5 = require("react");
737
+ var import_utils7 = require("@reblu/site-ui/utils");
533
738
  var import_site_contracts = require("@reblu/site-contracts");
534
- var import_jsx_runtime5 = require("react/jsx-runtime");
739
+ var import_jsx_runtime8 = require("react/jsx-runtime");
535
740
  var DATA_REQUEST_FIELD_LIMITS = Object.freeze({ name: 200, email: 320, details: 5e3 });
536
741
  var DEFAULT_DATA_REQUEST_FORM_LABELS = {
537
742
  requestTypeLabel: "Derecho que quieres ejercer",
@@ -557,7 +762,7 @@ var DEFAULT_DATA_REQUEST_FORM_LABELS = {
557
762
  nameRequired: "Indica tu nombre.",
558
763
  emailInvalid: "Introduce un email v\xE1lido.",
559
764
  consentRequired: "Debes aceptar el tratamiento de tus datos para gestionar la solicitud.",
560
- success: (deadline) => /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(import_jsx_runtime5.Fragment, { children: [
765
+ success: (deadline) => /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_jsx_runtime8.Fragment, { children: [
561
766
  "Hemos recibido tu solicitud. Te responderemos como m\xE1ximo el ",
562
767
  deadline,
563
768
  "."
@@ -606,18 +811,18 @@ function DataRequestForm({
606
811
  ...labels,
607
812
  requestTypes: { ...DEFAULT_DATA_REQUEST_FORM_LABELS.requestTypes, ...labels?.requestTypes }
608
813
  };
609
- const id = (0, import_react4.useId)();
610
- const [requestType, setRequestType] = (0, import_react4.useState)("");
611
- const [name, setName] = (0, import_react4.useState)("");
612
- const [email, setEmail] = (0, import_react4.useState)("");
613
- const [details, setDetails] = (0, import_react4.useState)("");
614
- const [consent, setConsent] = (0, import_react4.useState)(false);
615
- const [errors, setErrors] = (0, import_react4.useState)({});
616
- const [formError, setFormError] = (0, import_react4.useState)(null);
617
- const [submitting, setSubmitting] = (0, import_react4.useState)(false);
618
- const [ack, setAck] = (0, import_react4.useState)(null);
619
- const statusRef = (0, import_react4.useRef)(null);
620
- (0, import_react4.useEffect)(() => {
814
+ const id = (0, import_react5.useId)();
815
+ const [requestType, setRequestType] = (0, import_react5.useState)("");
816
+ const [name, setName] = (0, import_react5.useState)("");
817
+ const [email, setEmail] = (0, import_react5.useState)("");
818
+ const [details, setDetails] = (0, import_react5.useState)("");
819
+ const [consent, setConsent] = (0, import_react5.useState)(false);
820
+ const [errors, setErrors] = (0, import_react5.useState)({});
821
+ const [formError, setFormError] = (0, import_react5.useState)(null);
822
+ const [submitting, setSubmitting] = (0, import_react5.useState)(false);
823
+ const [ack, setAck] = (0, import_react5.useState)(null);
824
+ const statusRef = (0, import_react5.useRef)(null);
825
+ (0, import_react5.useEffect)(() => {
621
826
  if (ack) statusRef.current?.focus();
622
827
  }, [ack]);
623
828
  async function handleSubmit(event) {
@@ -668,21 +873,21 @@ function DataRequestForm({
668
873
  dateStyle: "long",
669
874
  timeStyle: "short"
670
875
  }).format(new Date(ack.slaDueAt));
671
- return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
876
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
672
877
  "div",
673
878
  {
674
879
  ref: statusRef,
675
880
  tabIndex: -1,
676
881
  role: "status",
677
882
  "data-slot": "data-request-form-success",
678
- className: (0, import_utils5.cn)("rounded-lg border border-border p-6 text-sm", className),
883
+ className: (0, import_utils7.cn)("rounded-lg border border-border p-6 text-sm", className),
679
884
  children: t.success(deadline)
680
885
  }
681
886
  );
682
887
  }
683
888
  const errorId = (field) => errors[field] ? `${id}-${field}-error` : void 0;
684
- const fieldError = (field) => errors[field] ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { id: `${id}-${field}-error`, className: "text-xs text-destructive", children: errors[field] }) : null;
685
- return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(
889
+ const fieldError = (field) => errors[field] ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("p", { id: `${id}-${field}-error`, className: "text-xs text-destructive", children: errors[field] }) : null;
890
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
686
891
  "form",
687
892
  {
688
893
  noValidate: true,
@@ -690,11 +895,11 @@ function DataRequestForm({
690
895
  onFocusCapture: onInteract,
691
896
  onPointerDown: onInteract,
692
897
  "data-slot": "data-request-form",
693
- className: (0, import_utils5.cn)("space-y-4", className),
898
+ className: (0, import_utils7.cn)("space-y-4", className),
694
899
  children: [
695
- /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "space-y-1", children: [
696
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("label", { htmlFor: `${id}-type`, className: "text-sm font-medium", children: t.requestTypeLabel }),
697
- /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(
900
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "space-y-1", children: [
901
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("label", { htmlFor: `${id}-type`, className: "text-sm font-medium", children: t.requestTypeLabel }),
902
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
698
903
  "select",
699
904
  {
700
905
  id: `${id}-type`,
@@ -702,18 +907,18 @@ function DataRequestForm({
702
907
  onChange: (e) => setRequestType(e.target.value),
703
908
  "aria-invalid": errors.requestType ? true : void 0,
704
909
  "aria-describedby": errorId("requestType"),
705
- className: (0, import_utils5.cn)(FIELD_CLASS, errors.requestType ? "border-destructive" : "border-border"),
910
+ className: (0, import_utils7.cn)(FIELD_CLASS, errors.requestType ? "border-destructive" : "border-border"),
706
911
  children: [
707
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("option", { value: "", children: t.requestTypePlaceholder }),
708
- requestTypes.map((type) => /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("option", { value: type, children: t.requestTypes[type] }, type))
912
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("option", { value: "", children: t.requestTypePlaceholder }),
913
+ requestTypes.map((type) => /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("option", { value: type, children: t.requestTypes[type] }, type))
709
914
  ]
710
915
  }
711
916
  ),
712
917
  fieldError("requestType")
713
918
  ] }),
714
- /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "space-y-1", children: [
715
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("label", { htmlFor: `${id}-name`, className: "text-sm font-medium", children: t.nameLabel }),
716
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
919
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "space-y-1", children: [
920
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("label", { htmlFor: `${id}-name`, className: "text-sm font-medium", children: t.nameLabel }),
921
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
717
922
  "input",
718
923
  {
719
924
  id: `${id}-name`,
@@ -724,14 +929,14 @@ function DataRequestForm({
724
929
  onChange: (e) => setName(e.target.value),
725
930
  "aria-invalid": errors.name ? true : void 0,
726
931
  "aria-describedby": errorId("name"),
727
- className: (0, import_utils5.cn)(FIELD_CLASS, errors.name ? "border-destructive" : "border-border")
932
+ className: (0, import_utils7.cn)(FIELD_CLASS, errors.name ? "border-destructive" : "border-border")
728
933
  }
729
934
  ),
730
935
  fieldError("name")
731
936
  ] }),
732
- /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "space-y-1", children: [
733
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("label", { htmlFor: `${id}-email`, className: "text-sm font-medium", children: t.emailLabel }),
734
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
937
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "space-y-1", children: [
938
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("label", { htmlFor: `${id}-email`, className: "text-sm font-medium", children: t.emailLabel }),
939
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
735
940
  "input",
736
941
  {
737
942
  id: `${id}-email`,
@@ -742,14 +947,14 @@ function DataRequestForm({
742
947
  onChange: (e) => setEmail(e.target.value),
743
948
  "aria-invalid": errors.email ? true : void 0,
744
949
  "aria-describedby": errorId("email"),
745
- className: (0, import_utils5.cn)(FIELD_CLASS, errors.email ? "border-destructive" : "border-border")
950
+ className: (0, import_utils7.cn)(FIELD_CLASS, errors.email ? "border-destructive" : "border-border")
746
951
  }
747
952
  ),
748
953
  fieldError("email")
749
954
  ] }),
750
- /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "space-y-1", children: [
751
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("label", { htmlFor: `${id}-details`, className: "text-sm font-medium", children: t.detailsLabel }),
752
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
955
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "space-y-1", children: [
956
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("label", { htmlFor: `${id}-details`, className: "text-sm font-medium", children: t.detailsLabel }),
957
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
753
958
  "textarea",
754
959
  {
755
960
  id: `${id}-details`,
@@ -758,13 +963,13 @@ function DataRequestForm({
758
963
  value: details,
759
964
  onChange: (e) => setDetails(e.target.value),
760
965
  placeholder: t.detailsPlaceholder,
761
- className: (0, import_utils5.cn)(FIELD_CLASS, "border-border")
966
+ className: (0, import_utils7.cn)(FIELD_CLASS, "border-border")
762
967
  }
763
968
  )
764
969
  ] }),
765
- /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "space-y-1", children: [
766
- /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "flex items-start gap-2", children: [
767
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
970
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "space-y-1", children: [
971
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "flex items-start gap-2", children: [
972
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
768
973
  "input",
769
974
  {
770
975
  id: `${id}-consent`,
@@ -776,17 +981,17 @@ function DataRequestForm({
776
981
  className: "mt-1"
777
982
  }
778
983
  ),
779
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("label", { htmlFor: `${id}-consent`, className: "text-sm text-foreground/80", children: t.consent })
984
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("label", { htmlFor: `${id}-consent`, className: "text-sm text-foreground/80", children: t.consent })
780
985
  ] }),
781
986
  fieldError("consent")
782
987
  ] }),
783
- formError && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { role: "alert", className: "text-sm text-destructive", children: formError }),
784
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
988
+ formError && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("p", { role: "alert", className: "text-sm text-destructive", children: formError }),
989
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
785
990
  "button",
786
991
  {
787
992
  type: "submit",
788
993
  disabled: submitting,
789
- className: "rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground disabled:opacity-60",
994
+ className: "cursor-pointer rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground disabled:cursor-not-allowed disabled:opacity-60",
790
995
  children: submitting ? t.submitting : t.submit
791
996
  }
792
997
  )
@@ -802,7 +1007,9 @@ function DataRequestForm({
802
1007
  DATA_REQUEST_FIELD_LIMITS,
803
1008
  DATA_REQUEST_TYPES,
804
1009
  DEFAULT_DATA_REQUEST_FORM_LABELS,
1010
+ DEFAULT_PRODUCT_CAROUSEL_LABELS,
805
1011
  DataRequestForm,
1012
+ ProductCarousel,
806
1013
  ShareButtons,
807
1014
  useCollection
808
1015
  });